Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 8fe6752c49e711fd9108e42f4bb8c91a3bf5a930


Parents : b2c620e
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-27T11:15:35-05:00

feat(clipboard): add clipboard utility functions for secure and insecure contexts, better text copy and read capabilities

Changes
Diff

diff --git a/meshchatx/src/frontend/js/ElectronUtils.js b/meshchatx/src/frontend/js/ElectronUtils.js
index 4361e4ad..4b32db89 100644
--- a/meshchatx/src/frontend/js/ElectronUtils.js
+++ b/meshchatx/src/frontend/js/ElectronUtils.js
@@ -1,3 +1,5 @@
+import { copyTextToClipboard as copyTextToClipboardWeb } from "./clipboardUtils.js";
+
class ElectronUtils {
static isElectron() {
return window.electron != null;
@@ -50,32 +52,7 @@ class ElectronUtils {
}
static async copyTextToClipboard(text) {
- if (text == null || text === "") {
- return false;
- }
- const s = String(text);
- try {
- if (navigator.clipboard?.writeText) {
- await navigator.clipboard.writeText(s);
- return true;
- }
- } catch {
- // fall through to execCommand
- }
- try {
- const ta = document.createElement("textarea");
- ta.value = s;
- ta.setAttribute("readonly", "");
- ta.style.position = "fixed";
- ta.style.left = "-9999px";
- document.body.appendChild(ta);
- ta.select();
- const ok = document.execCommand("copy");
- document.body.removeChild(ta);
- return ok;
- } catch {
- return false;
- }
+ return copyTextToClipboardWeb(text);
}
static async revealPathInFolderOrCopy(path, onCopiedWeb) {

diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js
index c1f66a83..34a3a35f 100644
--- a/meshchatx/src/frontend/js/GlobalState.js
+++ b/meshchatx/src/frontend/js/GlobalState.js
@@ -6,6 +6,7 @@ const globalState = reactive({
authEnabled: false,
authenticated: false,
detailedOutboundSendStatus: false,
+ messageTimestampGroupingEnabled: true,
unreadConversationsCount: 0,
activeCallTab: "phone",
blockedDestinations: [],

diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.js
index 236da8fa..f5f90236 100644
--- a/meshchatx/src/frontend/js/MarkdownRenderer.js
+++ b/meshchatx/src/frontend/js/MarkdownRenderer.js
@@ -49,7 +49,7 @@ export default class MarkdownRenderer {
// Inline code
text = text.replace(
/`([^`]+)`/g,
- '<code class="bg-black/10 dark:bg-white/10 px-1 rounded font-mono text-[0.9em]">$1</code>'
+ '<code class="bg-black/10 dark:bg-white/10 px-1 rounded-sm font-mono text-[0.9em]">$1</code>'
);
// Links

diff --git a/meshchatx/src/frontend/js/clipboardUtils.js b/meshchatx/src/frontend/js/clipboardUtils.js
new file mode 100644
index 00000000..bcd38703
--- /dev/null
+++ b/meshchatx/src/frontend/js/clipboardUtils.js
@@ -0,0 +1,82 @@
+/**
+ * Clipboard helpers for browsers without a secure context (e.g. http://0.0.0.0:8000)
+ * where navigator.clipboard may be missing or reject.
+ */
+
+/**
+ * Browsers set `false` on http://0.0.0.0 and similar; `undefined` in some test envs is treated as allowed.
+ * @returns {boolean}
+ */
+export function isWindowSecureContext() {
+ if (typeof window === "undefined") {
+ return false;
+ }
+ return window.isSecureContext !== false;
+}
+
+/**
+ * Whether async clipboard read is expected to work (secure context + API present).
+ * @returns {boolean}
+ */
+export function canUseAsyncClipboardRead() {
+ return (
+ typeof navigator !== "undefined" &&
+ !!navigator.clipboard &&
+ typeof navigator.clipboard.readText === "function" &&
+ isWindowSecureContext()
+ );
+}
+
+/**
+ * @param {string} text
+ * @returns {Promise<boolean>}
+ */
+export async function copyTextToClipboard(text) {
+ if (text == null || text === "") {
+ return false;
+ }
+ const s = String(text);
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(s);
+ return true;
+ } catch {
+ // fall through to execCommand
+ }
+ }
+ try {
+ const ta = document.createElement("textarea");
+ ta.value = s;
+ ta.setAttribute("readonly", "");
+ ta.setAttribute("aria-hidden", "true");
+ ta.style.position = "fixed";
+ ta.style.left = "-9999px";
+ ta.style.top = "0";
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ const ok = document.execCommand("copy");
+ document.body.removeChild(ta);
+ return ok;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * @returns {Promise<{ ok: true, text: string } | { ok: false, code: string }>}
+ */
+export async function readTextFromClipboard() {
+ if (typeof navigator === "undefined" || !navigator.clipboard?.readText) {
+ return { ok: false, code: "unavailable" };
+ }
+ if (!isWindowSecureContext()) {
+ return { ok: false, code: "insecure_context" };
+ }
+ try {
+ const text = await navigator.clipboard.readText();
+ return { ok: true, text: text ?? "" };
+ } catch {
+ return { ok: false, code: "denied" };
+ }
+}

diff --git a/meshchatx/src/frontend/js/localMessageRetention.js b/meshchatx/src/frontend/js/localMessageRetention.js
new file mode 100644
index 00000000..441c3c32
--- /dev/null
+++ b/meshchatx/src/frontend/js/localMessageRetention.js
@@ -0,0 +1,20 @@
+const UNIT_DAYS = "days";
+const UNIT_MONTHS = "months";
+
+const MAX_DAYS = 10_000;
+const MAX_MONTHS = 120;
+
+/**
+ * @param {unknown} value
+ * @param {unknown} unit
+ * @returns {{ value: number, unit: string }}
+ */
+export function normalizeRetentionValue(value, unit) {
+ const u = String(unit) === UNIT_MONTHS ? UNIT_MONTHS : UNIT_DAYS;
+ const cap = u === UNIT_MONTHS ? MAX_MONTHS : MAX_DAYS;
+ const n = Number(value);
+ const v = Number.isFinite(n) ? Math.trunc(n) : 1;
+ return { value: Math.min(Math.max(1, v), cap), unit: u };
+}
+
+export { MAX_DAYS as MAX_RETENTION_DAYS, MAX_MONTHS as MAX_RETENTION_MONTHS, UNIT_DAYS, UNIT_MONTHS };

diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.js
index 3de0d411..75267968 100644
--- a/meshchatx/src/frontend/js/lxmfReactions.js
+++ b/meshchatx/src/frontend/js/lxmfReactions.js
@@ -60,8 +60,11 @@ export function lxmfConversationListPreview(msg, { myLxmfAddressHash, peerDispla
}
export function mergeLxmfReactionRowsIntoMessages(messages) {
- if (!Array.isArray(messages) || messages.length === 0) {
- return messages;
+ if (!Array.isArray(messages)) {
+ return [];
+ }
+ if (messages.length === 0) {
+ return [];
}
const parents = [];
const reactions = [];
@@ -75,13 +78,13 @@ export function mergeLxmfReactionRowsIntoMessages(messages) {
parents.push({ ...m, reactions: [] });
}
}
- const byHash = new Map(parents.map((p) => [p.hash, p]));
+ const byHash = new Map(parents.map((p) => [String(p.hash || "").toLowerCase(), p]));
for (const r of reactions) {
const targetId = r.reaction_to;
if (!targetId) {
continue;
}
- const parent = byHash.get(targetId);
+ const parent = byHash.get(String(targetId).toLowerCase());
if (!parent) {
continue;
}

diff --git a/meshchatx/src/frontend/js/messageDisplayLimits.js b/meshchatx/src/frontend/js/messageDisplayLimits.js
new file mode 100644
index 00000000..81b21aa0
--- /dev/null
+++ b/meshchatx/src/frontend/js/messageDisplayLimits.js
@@ -0,0 +1,13 @@
+/**
+ * Inline message body and raw modal use this limit so huge LXMF bodies
+ * do not lock the UI (markdown/DOM cost). Copy still sends full text.
+ */
+export const MESSAGE_BODY_MAX_DISPLAY_CHARS = 32000;
+
+/**
+ * @param {unknown} content
+ * @returns {boolean}
+ */
+export function isStringTooLargeForInlineDisplay(content) {
+ return typeof content === "string" && content.length > MESSAGE_BODY_MAX_DISPLAY_CHARS;
+}

diff --git a/meshchatx/src/frontend/js/messageTimestampGrouping.js b/meshchatx/src/frontend/js/messageTimestampGrouping.js
new file mode 100644
index 00000000..07726e41
--- /dev/null
+++ b/meshchatx/src/frontend/js/messageTimestampGrouping.js
@@ -0,0 +1,127 @@
+/** Gap after which the next message starts a new time cluster (ms). */
+export const TIMESTAMP_CLUSTER_GAP_MS = 5 * 60 * 1000;
+
+/**
+ * @param {unknown} datetimeString
+ * @returns {Date | null}
+ */
+export function parseMessageDate(datetimeString) {
+ if (!datetimeString) {
+ return null;
+ }
+ let dateString = String(datetimeString);
+ if (!dateString.includes("Z") && !dateString.includes("+")) {
+ dateString = dateString.replace(" ", "T") + "Z";
+ }
+ const date = new Date(dateString);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+/**
+ * Local calendar day key for grouping.
+ * @param {Date} d
+ * @returns {string | null}
+ */
+export function calendarDayKeyFromDate(d) {
+ if (!d || Number.isNaN(d.getTime())) {
+ return null;
+ }
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+
+/**
+ * @param {unknown} group
+ * @returns {{ min: number, max: number }}
+ */
+export function displayGroupSortBoundsMs(group) {
+ if (!group || typeof group !== "object") {
+ return { min: 0, max: 0 };
+ }
+ if (group.type === "single") {
+ const t = parseMessageDate(group.chatItem?.lxmf_message?.created_at)?.getTime() ?? 0;
+ return { min: t, max: t };
+ }
+ if (group.type === "imageGroup" && Array.isArray(group.items) && group.items.length > 0) {
+ let minT = Infinity;
+ let maxT = -Infinity;
+ for (const it of group.items) {
+ const t = parseMessageDate(it?.lxmf_message?.created_at)?.getTime();
+ if (t && !Number.isNaN(t)) {
+ minT = Math.min(minT, t);
+ maxT = Math.max(maxT, t);
+ }
+ }
+ if (minT === Infinity) {
+ return { min: 0, max: 0 };
+ }
+ return { min: minT, max: maxT };
+ }
+ return { min: 0, max: 0 };
+}
+
+/**
+ * @param {unknown} group
+ * @returns {boolean}
+ */
+export function displayGroupIsOutbound(group) {
+ if (group?.type === "single") {
+ return !!group.chatItem?.is_outbound;
+ }
+ if (group?.type === "imageGroup" && group.items?.[0]) {
+ return !!group.items[0].is_outbound;
+ }
+ return false;
+}
+
+/**
+ * Inserts date dividers and sets `showTimestamp` on each message row (true on the
+ * chronologically last message of each cluster, i.e. the bubble that should show the time).
+ * @param {unknown[]} groupsOldestFirst
+ * @param {{ groupingEnabled?: boolean }} [options]
+ * @returns {unknown[]}
+ */
+export function buildTimestampGroupedOldestFirst(groupsOldestFirst, options = {}) {
+ const groupingEnabled = options.groupingEnabled !== false;
+ if (!groupsOldestFirst?.length) {
+ return [];
+ }
+ const onlyMsg = groupsOldestFirst.filter((g) => g && (g.type === "single" || g.type === "imageGroup"));
+ if (!groupingEnabled) {
+ return onlyMsg.map((g) => ({ ...g, showTimestamp: true }));
+ }
+ const showFlags = [];
+ for (let i = 0; i < onlyMsg.length; i++) {
+ const g = onlyMsg[i];
+ const next = onlyMsg[i + 1];
+ let show = true;
+ if (next) {
+ const cb = displayGroupSortBoundsMs(g);
+ const nb = displayGroupSortBoundsMs(next);
+ const sameSide = displayGroupIsOutbound(g) === displayGroupIsOutbound(next);
+ const gap = nb.min - cb.max;
+ show = !sameSide || gap >= TIMESTAMP_CLUSTER_GAP_MS || gap < 0;
+ }
+ showFlags.push(show);
+ }
+
+ const out = [];
+ let prevDayKey = null;
+ for (let i = 0; i < onlyMsg.length; i++) {
+ const g = onlyMsg[i];
+ const bounds = displayGroupSortBoundsMs(g);
+ const dayKey = bounds.min ? calendarDayKeyFromDate(new Date(bounds.min)) : null;
+ if (dayKey && dayKey !== prevDayKey) {
+ out.push({
+ type: "dateDivider",
+ dayKey,
+ key: `date-div-${dayKey}-${out.length}`,
+ });
+ prevDayKey = dayKey;
+ }
+ out.push({ ...g, showTimestamp: showFlags[i] });
+ }
+ return out;
+}

diff --git a/meshchatx/src/frontend/js/reticulumPathfinding.js b/meshchatx/src/frontend/js/reticulumPathfinding.js
new file mode 100644
index 00000000..91806552
--- /dev/null
+++ b/meshchatx/src/frontend/js/reticulumPathfinding.js
@@ -0,0 +1,63 @@
+const destinationPath = (hash) => `/api/v1/destination/${hash}/path`;
+
+/**
+ * @param {import("axios").AxiosInstance} api
+ * @param {string} hash
+ * @param {{ request?: "0" | "1" | boolean, timeout?: number } & Record<string, string | number | boolean | undefined>} [params]
+ */
+export function getDestinationPath(api, hash, params) {
+ const q = { ...params };
+ if (q.request === true) {
+ q.request = "1";
+ } else if (q.request === false) {
+ q.request = "0";
+ }
+ return api.get(destinationPath(hash), { params: q });
+}
+
+export function postRequestPath(api, hash) {
+ return api.post(`/api/v1/destination/${hash}/request-path`);
+}
+
+export function postDropPath(api, hash) {
+ return api.post(`/api/v1/destination/${hash}/drop-path`);
+}
+
+/**
+ * @typedef {"quick" | "force" | "drop_then_request"} PathFinderMode
+ */
+
+/**
+ * @param {import("axios").AxiosInstance} api
+ * @param {string} hash
+ * @param {PathFinderMode} mode
+ * @param {{ forceTimeout?: number, onDropPathError?: (e: unknown) => void }} [options]
+ */
+export async function runDestinationPathFinder(api, hash, mode, options) {
+ const forceTimeout = options?.forceTimeout ?? 15;
+ if (mode === "quick") {
+ await postRequestPath(api, hash);
+ return { ok: true, path: null };
+ }
+ if (mode === "force") {
+ const res = await getDestinationPath(api, hash, {
+ request: "1",
+ timeout: forceTimeout,
+ });
+ return { ok: true, path: res.data?.path ?? null };
+ }
+ if (mode === "drop_then_request") {
+ try {
+ await postDropPath(api, hash);
+ } catch (e) {
+ if (options?.onDropPathError) {
+ options.onDropPathError(e);
+ } else {
+ console.warn("drop-path failed (continuing)", e);
+ }
+ }
+ await postRequestPath(api, hash);
+ return { ok: true, path: null };
+ }
+ throw new Error(`unknown path finder mode: ${mode}`);
+}

diff --git a/meshchatx/src/frontend/js/settingsSearchUtils.js b/meshchatx/src/frontend/js/settingsSearchUtils.js
new file mode 100644
index 00000000..a27fbe7f
--- /dev/null
+++ b/meshchatx/src/frontend/js/settingsSearchUtils.js
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+const ZW_RE = /[\u200B-\u200D\uFEFF]/g;
+
+/**
+ * @param {unknown} raw
+ * @returns {string}
+ */
+export function normalizeSearchString(raw) {
+ if (raw == null) return "";
+ const s = String(raw).replace(ZW_RE, "");
+ return s.trim();
+}
+
+/**
+ * Lowercase, strip combining marks for loose matching, normalize sharp s for German keyboards.
+ * @param {string} str
+ * @returns {string}
+ */
+export function foldForSearch(str) {
+ if (!str) return "";
+ let out = String(str).toLowerCase();
+ try {
+ out = out.normalize("NFD").replace(/\p{M}/gu, "");
+ } catch {
+ // Unicode property escapes unsupported in very old runtimes
+ }
+ return out.replace(/\u00df/g, "ss");
+}
+
+/**
+ * @param {string} normalizedTrimmed
+ * @returns {string[]}
+ */
+export function tokenizeSettingsQuery(normalizedTrimmed) {
+ if (!normalizedTrimmed) return [];
+ return normalizedTrimmed
+ .toLowerCase()
+ .split(/\s+/)
+ .map((t) => foldForSearch(t))
+ .filter((t) => t.length > 0);
+}
+
+/**
+ * @param {string} text
+ * @param {(key: string) => string} translateFn
+ * @returns {string}
+ */
+function resolveSnippet(text, translateFn) {
+ if (!text) return "";
+ const s = String(text);
+ const content = s.includes(".") ? translateFn(s) : s;
+ return foldForSearch(content);
+}
+
+/**
+ * Settings section search: empty query shows all; otherwise every whitespace-separated
+ * token must appear somewhere in the combined (translated) keyword haystack.
+ *
+ * @param {string[]} texts raw strings or i18n keys (keys contain a dot)
+ * @param {(key: string) => string} translateFn
+ * @param {string} rawQuery
+ * @returns {boolean}
+ */
+export function matchesSettingSearch(texts, translateFn, rawQuery) {
+ const normalized = normalizeSearchString(rawQuery);
+ if (!normalized) return true;
+ const tokens = tokenizeSettingsQuery(normalized);
+ if (!tokens.length) return true;
+ const haystack = texts
+ .map((t) => resolveSnippet(t, translateFn))
+ .filter(Boolean)
+ .join(" ");
+ if (!haystack) return false;
+ return tokens.every((tok) => haystack.includes(tok));
+}


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────